Skip to content

♊ fix: Sanitize MCP Tool Schemas for Gemini/Vertex Compatibility#13623

Merged
danny-avila merged 5 commits into
devfrom
claude/musing-bassi-052c94
Jun 9, 2026
Merged

♊ fix: Sanitize MCP Tool Schemas for Gemini/Vertex Compatibility#13623
danny-avila merged 5 commits into
devfrom
claude/musing-bassi-052c94

Conversation

@danny-avila

Copy link
Copy Markdown
Owner

Summary

I fixed a crash where Gemini/Vertex AI agents fail on MCP tools that ship union-typed parameter schemas (e.g. discriminated unions like the GitHub MCP issue_write tool). These tools work on OpenAI and Claude, but @langchain/google-common's zod_to_gemini_parameters throws "Gemini cannot handle union types" on any genuine anyOf/oneOf, aborting the run. Resolves #13612.

  • Added flattenJsonSchemaUnions in packages/api/src/mcp/zod.ts, which recursively collapses anyOf/oneOf to the first non-null member and multi-entry type arrays (e.g. ["string","null"]) to a single type, marking the field nullable when a null member is dropped.
  • Applied the flatten inside createToolInstance's existing isGoogle branch in api/server/services/MCP.js, alongside the empty-object fallback, so only the Google/Vertex path is altered and OpenAI/Claude schemas remain byte-identical.
  • Kept the transform out of the shared normalizeJsonSchema (which must stay lossless for all providers), since flattening a union is lossy and Gemini-specific.
  • Documented the function as Gemini-only and lossy by design, mirroring langchain's own "use a flat object with optional fields" guidance.
  • Added 13 unit tests covering discriminated unions, nested unions, nullable collapse, type-array collapse, no-op on union-free schemas, and primitive inputs.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

I verified the fix end-to-end against the real @langchain/google-common converter rather than a mock:

  • Confirmed the GitHub issue_write-style discriminated-union schema throws the exact zod_to_gemini_parameters: Gemini cannot handle union types error today, and is accepted by the same converter after flattenJsonSchemaUnions runs.
  • Confirmed the flatten is a no-op for union-free schemas, and it only executes in the isGoogle branch — so the OpenAI/Claude tool-binding path is unchanged.

To reproduce: configure a Google/Vertex Gemini endpoint, attach an MCP server whose tool schema uses anyOf/oneOf (e.g. the GitHub MCP), and prompt the model to call that tool — previously the run aborted, now it completes.

Test Configuration:

  • cd packages/api && npx jest src/mcp/__tests__/zod.spec.ts — 92/92 pass
  • cd api && npx jest server/services/__tests__/MCP.spec.js — 11/11 pass
  • @librechat/api builds clean; ESLint clean on all changed files

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

Copilot AI review requested due to automatic review settings June 9, 2026 15:47
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Gemini/Vertex AI agent crashes when MCP tools provide union-typed JSON parameter schemas (anyOf/oneOf or multi-type type arrays) by flattening those unions only on the Google/Vertex tool-schema path, preserving existing behavior for OpenAI/Claude.

Changes:

  • Added flattenJsonSchemaUnions() (Gemini-specific, intentionally lossy) to collapse anyOf/oneOf and multi-type type arrays into a single concrete schema with nullable marking when null is removed.
  • Applied the flattening step inside createToolInstance() for Google/Vertex providers only, after $ref resolution and existing schema normalization.
  • Added unit tests covering union flattening behavior across nested/object/array schemas and nullable/type-array cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/api/src/mcp/zod.ts Adds the Gemini/Vertex-only union-flattening transform for JSON schemas.
packages/api/src/mcp/tests/zod.spec.ts Adds unit tests validating union flattening, nullability handling, and no-op behavior.
api/server/services/MCP.js Gates and applies union flattening only for Google/Vertex tool schema creation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f925783403

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


const rest = { ...current };
delete rest[unionKey];
current = { ...rest, ...chosen };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve parent fields when flattening unions

When a schema has common object fields plus an anyOf/oneOf branch, this spread order lets the selected branch replace the parent's properties and required arrays. For example, {type:'object', properties:{repo,...}, required:['repo'], anyOf:[{properties:{title:...}, required:['title']}]} flattens to a schema that no longer advertises repo as an argument, so Gemini can omit valid required MCP parameters and the server-side tool call fails even though those fields were outside the union. Merge object properties and combine required entries instead of letting the branch overwrite them wholesale.

Useful? React with 👍 / 👎.

Comment thread api/server/services/MCP.js Outdated
if (schema && isGoogle) {
// Gemini/Vertex AI reject union schemas; collapse them so MCP tools that ship
// unions don't crash the Google endpoint (they work as-is on OpenAI/Claude).
schema = flattenJsonSchemaUnions(schema);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flatten schemas used for MCP tool definitions

This only flattens the schema passed into the LangChain tool instance; the same MCP tool still carries the raw parameters on mcpJsonSchema, and the definitions-only/deferred-tool path (extractMCPToolDefinition/loadToolDefinitions) builds serializable tool definitions from that field. In event-driven or deferred MCP flows on Google/Vertex, a tool with anyOf/oneOf can therefore still reach the Google converter unflattened and fail with the same union-schema error this change is meant to avoid. Store or propagate the Google-flattened schema for those definitions as well.

Useful? React with 👍 / 👎.

Comment thread packages/api/src/mcp/zod.ts Outdated
Comment on lines +416 to +422
const { type, nullable } = collapseTypeArray(value);
if (type !== undefined) {
result['type'] = type;
}
if (nullable) {
result['nullable'] = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove null enum values when marking nullable

When this handles nullable enum schemas such as { type: ['string', 'null'], enum: ['open', 'closed', null] }, it rewrites the type to string and adds nullable: true but leaves null in the enum. Gemini function schemas only accept homogeneous primitive enum values, so nullable enum parameters from MCP servers can still be rejected even after the union flattening runs. Filter the dropped null out of enum when setting nullable.

Useful? React with 👍 / 👎.

@danny-avila

Copy link
Copy Markdown
Owner Author

Thanks — all three addressed in ce5b1fd2bb:

Finding 1 (preserve parent fields): collapseSchemaUnion now merges the chosen branch into the parent instead of overwriting — object properties are shallow-merged and required arrays are unioned — so fields declared outside the union (e.g. an always-required repo) survive the collapse. Verified against the real @langchain/google-common converter: a {properties:{repo}, required:['repo'], anyOf:[…]} schema now keeps repo and required:['repo'] and is accepted.

Finding 3 (null enum values): when a union/type array makes a field nullable, the dropped null is now filtered out of a sibling enum, e.g. {type:['string','null'], enum:['open','closed',null]}{type:'string', enum:['open','closed'], nullable:true}.

Finding 2 (definitions/deferred path): the flatten was only on the bound tool instance. Now the Google-flattened schema also reaches the definitions path: provider is threaded into loadToolDefinitions (flattening MCP params when Google/Vertex), and createMCPTool stores the flattened schema on mcpJsonSchema, so extractMCPToolDefinition/loadToolDefinitions no longer emit raw unions on Google in deferred/event-driven flows.

Coverage: added unit tests for the merge + null-enum cases (zod.spec.ts) and a provider-gated definitions test (definitions.spec.ts). Suites: zod 97/97, definitions 26/26, api MCP + ToolService 66/66; build + ESLint clean.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`@langchain/google-common`'s `zod_to_gemini_parameters` throws "Gemini cannot
handle union types" on any genuine `anyOf`/`oneOf` (e.g. discriminated unions),
so MCP tools shipping union-typed schemas crash on the Google endpoint while
working fine on OpenAI/Claude.

Add `flattenJsonSchemaUnions` (packages/api) to collapse unions to their first
non-null member and multi-entry `type` arrays to a single nullable type, and
apply it in `createToolInstance`'s existing `isGoogle` branch so only the
Google/Vertex path is affected. Lossy by design, mirroring the existing
empty-object fallback.

Closes #13612
…r definitions path

- Preserve parent-level `properties`/`required` when collapsing a union: merge the
  chosen branch into the parent instead of overwriting, so args declared outside the
  union (e.g. always-required fields) still reach Gemini.
- Drop the `null` member from `enum` when a union/type-array makes a field nullable,
  keeping Gemini's required homogeneous-enum invariant.
- Propagate the Google-flattened schema to the definitions/deferred-tool path:
  thread `provider` into `loadToolDefinitions` and flatten there, and store the
  flattened schema on `mcpJsonSchema` so `extractMCPToolDefinition` no longer emits
  raw unions on Google/Vertex.
@danny-avila danny-avila force-pushed the claude/musing-bassi-052c94 branch from 9490ebd to acfac4e Compare June 9, 2026 16:27
@danny-avila danny-avila changed the base branch from main to dev June 9, 2026 16:28
The union flatten alone wasn't enough — real GitHub MCP tools on Gemini also 400
with `Invalid value ... (TYPE_STRING), true`, because Gemini's function-calling
Schema (https://ai.google.dev/api/caching#Schema) accepts only a restricted JSON
Schema subset, and `enum` is `Type.STRING`-only.

Rename `flattenJsonSchemaUnions` → `sanitizeGeminiSchema` and broaden it (one pass,
Gemini-gated) to cover the documented subset:

- Keep only string `enum` values; drop the keyword for non-string types (fixes the
  reported boolean-enum 400, incl. boolean `const` normalized to `enum: [true]`).
- `const` → single-value string enum, or drop if non-string.
- Merge `allOf` intersections; fold `exclusiveMinimum`/`exclusiveMaximum` into
  `minimum`/`maximum`.
- Strip unsupported keywords: `additionalProperties`, `default`, `$schema`, `$id`.
- (Existing) collapse `anyOf`/`oneOf`, multi-entry `type` arrays, nullable.

Grounded in Google's Schema docs rather than reverse-engineered from 400s. Verified
end-to-end against the real `@langchain/google-common` converter. Complements
danny-avila/agents#232 (langchain bump), which defers schema flattening to LibreChat.
@danny-avila danny-avila changed the title ♊ fix: Flatten Union Schemas for Gemini MCP Tool Compatibility ♊ fix: Sanitize MCP Tool Schemas for Gemini/Vertex Compatibility Jun 9, 2026
@danny-avila

Copy link
Copy Markdown
Owner Author

Broadened the fix in 16da0fd2d7 after real GitHub MCP tools surfaced a second Gemini-only 400 beyond unions:

Invalid value at 'tools[0].function_declarations[22].parameters
  .properties[3].value.items.properties[0].value.enum[0]' (TYPE_STRING), true

Gemini's function-calling Schema accepts only a restricted JSON Schema subset, and enum is Type.STRING-only — so a boolean enum (incl. a boolean const that normalizeJsonSchema turns into enum: [true]) is rejected. Renamed flattenJsonSchemaUnionssanitizeGeminiSchema and broadened it (one pass, Gemini-gated) to the documented subset:

  • string-only enum (drop non-string values; drop the keyword for non-string types)
  • const → single-value string enum, else drop
  • merge allOf; fold exclusiveMinimum/exclusiveMaximumminimum/maximum
  • strip additionalProperties, default, $schema, $id
  • (existing) collapse anyOf/oneOf, multi-entry type arrays, nullable

Grounded in Google's Schema reference rather than reverse-engineered from 400s, and verified end-to-end against the real @langchain/google-common converter. Complements danny-avila/agents#232 (langchain stack bump), whose description explicitly defers schema flattening to LibreChat.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16da0fd2d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +540 to +541
if (stringValues.length > 0) {
result['enum'] = stringValues;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop enums when the effective type is not string

When a MCP schema has a mixed typed enum (for example type: ['integer', 'string'] with enum: [1, 'auto']), the sanitizer collapses the type to the first non-null type but still keeps any string enum values, producing a Gemini schema like type: 'integer', enum: ['auto']. This contradicts the intended “drop the keyword for non-string types” behavior above and can still send Gemini an invalid/misleading enum; the enum should only be retained when the effective sanitized type is string (or when the type is made string).

Useful? React with 👍 / 👎.

Codex review: a mixed-type enum like `type: ['integer','string'], enum: [1,'auto']`
collapsed the type to `integer` but still kept the string value `'auto'`, yielding
`{type:'integer', enum:['auto']}` — a non-string type with an enum, which Gemini
rejects. Keep `enum` only when the effective collapsed type is string (or unset),
and stamp `type: 'string'` on a surviving typeless enum (e.g. a string `const`
discriminator) so it satisfies Gemini's Type.STRING enum requirement.
@danny-avila

Copy link
Copy Markdown
Owner Author

Fixed in 4d636103fe. The enum is now gated on the effective (collapsed) type: it's kept only when that type is string (or unset), and dropped entirely for non-string types. So type: ['integer','string'], enum: [1,'auto']{type:'integer'} (no stray enum), while ['string','integer']{type:'string', enum:['auto']}. A surviving typeless enum (e.g. a string const discriminator) now also gets type: 'string' stamped on it to satisfy Type.STRING. Added unit coverage for the mixed-type, numeric-enum, and typeless-enum cases; re-verified the invariant "no enum on a non-string node" across a torture schema against the real google-common converter.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila danny-avila merged commit 6c36d80 into dev Jun 9, 2026
20 of 21 checks passed
@danny-avila danny-avila deleted the claude/musing-bassi-052c94 branch June 9, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Gemini can't handle MCPs very well

2 participants